package de.lighti.starcraft;

import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.StringReader;
import java.lang.reflect.InvocationTargetException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SocketChannel;
import java.nio.charset.Charset;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.HashMap;
import java.util.Map;
import java.util.Properties;
import java.util.logging.Logger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import de.lighti.starcraft.model.Match;
import de.lighti.starcraft.model.StarcraftBot;
import de.lighti.starcraft.model.StarcraftMap;
import de.lighti.starcraft.network.ByteBufferBackedInputStream;
import de.lighti.starcraft.network.Message;
import de.lighti.starcraft.network.Message.MESSAGE_TYPE;
import de.lighti.starcraft.util.GameDirHandler;

public class Client implements Runnable {
    private final static Logger LOGGER = Logger.getLogger( Client.class.getName() );

    private final static String SETTINGS_PATH = ".\\..\\Settings\\";

    private final static String CLIENTS_INI = SETTINGS_PATH + "Client.ini";

    private static Properties loadProperties() throws IOException {
        final byte[] encoded = Files.readAllBytes( Paths.get( CLIENTS_INI ) );

        final String contents = Charset.defaultCharset().decode( ByteBuffer.wrap( encoded ) ).toString();
        final Properties props = new Properties();
        props.load( new StringReader( contents.replace( "\\", "\\\\" ) ) );
        return props;

    }

    public static void main( String[] args ) {
        try {
            final Client c = new Client();
            c.run();

        }
        catch (final Exception e) {
            LOGGER.severe( e.getLocalizedMessage() );
        }
    }

    private SocketChannel sc;
    private String starcraftBasePath;

    private final String chaosLauncherPath;

    private final static String STRING_STARCRAFT = "Program";

    private final static String STRING_CHAOS_LAUNCHER = "ChaosLauncher";

    private final static String STARCRAFT_INSTALL_DIR_PATH = "SOFTWARE\\Blizzard Entertainment\\Starcraft";
    private final static String STARCRAFT_INSTALL_DIR_PATH_64 = "SOFTWARE\\Wow6432Node\\Blizzard Entertainment\\Starcraft";

    private final static String STARCRAFT_INSTALL_DIR_KEY = "InstallPath";

    private final static String GAMESTATE_FILE = "gameState.txt";

    private long waitMs;

    private long timeOutThreshold;

    private static final String STRING_SERVER = "Server";

    private static final String STRING_TIMEOUT = "GameTimeout";
    private static final String STRING_CHECK_RESPONDING = "CheckOnProcess";

    private static final String STRING_TICKRATE = "GameStateTickRate";

    private static final long DEFAULT_TIMEOUT_THRESHOLD = 60000l;

    private static final long DEFAULT_TICKRATE = 1000l;

    private final Map<String, StarcraftBot> bots;

    private final Map<String, StarcraftMap> maps;
    private boolean checkIfProcessIsResponding;

    private String host;
    private int port;

    public Client() throws IOException {
        bots = new HashMap<String, StarcraftBot>();
        maps = new HashMap<String, StarcraftMap>();

        final Properties p = loadProperties();

        starcraftBasePath = p.getProperty( STRING_STARCRAFT );
        if (starcraftBasePath == null || starcraftBasePath.isEmpty()) {
            LOGGER.warning( "Path to Starcraft basedir not configured. Trying to read out registry" );

            try {
                starcraftBasePath = WinRegistry.readString( WinRegistry.HKEY_LOCAL_MACHINE, //HKEY
                                STARCRAFT_INSTALL_DIR_PATH, //Key
                                STARCRAFT_INSTALL_DIR_KEY );
                if (starcraftBasePath == null) {
                    //Try the 64-bit reg entry
                    starcraftBasePath = WinRegistry.readString( WinRegistry.HKEY_LOCAL_MACHINE, //HKEY
                                    STARCRAFT_INSTALL_DIR_PATH_64, //Key
                                    STARCRAFT_INSTALL_DIR_KEY );
                }
            }
            catch (IllegalArgumentException | IllegalAccessException | InvocationTargetException e) {
                //If this fails as well, we have no chance of recovery
                throw new RuntimeException( e );
            }
        }
        final File starcraftDir = new File( starcraftBasePath );
        if (!starcraftDir.canWrite() || !starcraftDir.isDirectory()) {
            throw new IllegalStateException( "Starcraft path" + starcraftBasePath + " either not found not writeable" );
        }
        else {
            LOGGER.info( "Found Starcraft in " + starcraftBasePath );
            GameDirHandler.setStarcraftBaseBath( starcraftBasePath );
        }

        chaosLauncherPath = p.getProperty( STRING_CHAOS_LAUNCHER );
        final File chaosLauncherExe = new File( chaosLauncherPath );
        if (chaosLauncherPath == null || !chaosLauncherExe.canExecute() || chaosLauncherExe.isDirectory()) {
            throw new IllegalStateException( "No path to Chaos Launcher defined or file not executable" );
        }
        else {
            LOGGER.info( "ChaosLauncher configured to " + chaosLauncherPath );
        }

        setConnection( p.getProperty( STRING_SERVER ) );
        setTimeoutThreshold( p.getProperty( STRING_TIMEOUT ) );
        setTickrate( p.getProperty( STRING_TICKRATE ) );
        setCheckOnProcess( p.getProperty( STRING_CHECK_RESPONDING ) );
    }

    private void cleanup( Win32Process chaosProcess ) throws IOException {
        final File gameStateFile = new File( starcraftBasePath + "\\" + GAMESTATE_FILE );
        if (gameStateFile.exists()) {
            gameStateFile.delete();
        }
        if (chaosProcess != null) {
            chaosProcess.terminate();
        }
        GameDirHandler.cleanupGamedir();

    }

    private void connect() throws IOException {
        sc = SocketChannel.open();

        //Set it to blocking. Clients do synchronous data transfer and we don't care about throughput
        //Saves us the whole selector hassle.       
        sc.configureBlocking( true );

        final InetSocketAddress address = new InetSocketAddress( host, port );
        LOGGER.info( "Trying to connect to " + address );
        sc.connect( address );

        sc.finishConnect();
    }

    private void disconnect() {
        if (sc != null) {
            try {
                sc.close();
            }
            catch (final IOException e) {

            }
        }
    }

    private void ensureBotIsOnDisc( StarcraftBot homeBot ) throws IOException {
        if (homeBot == null) {
            throw new IllegalArgumentException( "home bot must not be null" );
        }

        if (!bots.containsKey( homeBot.getName() )) {
            //clone the match bot and request its binary content
            try {
                final StarcraftBot clone = (StarcraftBot) homeBot.clone();
                final Message m = new Message( MESSAGE_TYPE.REQUEST_BINARY_CONTENT, homeBot );
                sendMessage( m );

                final Message reply = receiveMessage();
                if (reply.getType() != MESSAGE_TYPE.REQUEST_BINARY_CONTENT) {
                    throw new IllegalStateException( "Server send unexpected reply: " + reply );
                }
                final StarcraftBot payload = (StarcraftBot) reply.getPayload();
                clone.setBinaryContent( payload.getBinaryContent() );
                LOGGER.info( "Received " + clone.getBinaryContent().size() + " file for bot " + clone.getName() );
                bots.put( clone.getName(), clone );
            }
            catch (final CloneNotSupportedException e) {
                throw new RuntimeException( e );
            }
        }
        final StarcraftBot bot = bots.get( homeBot.getName() );
        GameDirHandler.prepBot( bot );

    }

    private void ensureMapIsOnDisc( StarcraftMap map ) throws IOException {
        if (map == null) {
            throw new IllegalArgumentException( "map must not be null" );
        }
        if (!maps.containsKey( map.getName() )) {
            StarcraftMap clone;
            try {
                clone = (StarcraftMap) map.clone();
            }
            catch (final CloneNotSupportedException e) {
                throw new RuntimeException( e );
            }
            final Message m = new Message( MESSAGE_TYPE.REQUEST_BINARY_CONTENT, map );
            sendMessage( m );

            final Message reply = receiveMessage();
            if (reply.getType() != MESSAGE_TYPE.REQUEST_BINARY_CONTENT) {
                throw new IllegalStateException( "Server send unexpected reply: " + reply );
            }
            final StarcraftMap payload = (StarcraftMap) reply.getPayload();
            clone.setBinaryContent( payload.getBinaryContent() );
            LOGGER.info( "Received file for map " + clone.getName() );
            maps.put( clone.getName(), clone );
        }
        final StarcraftMap localMap = maps.get( map.getName() );
        GameDirHandler.prepMap( localMap );
    }

    private void playGame( Match match, Boolean isHost ) throws IOException {
        final long starttime = System.currentTimeMillis();

        //Write BWAPI.ini, copy files etc
        GameDirHandler.prepareGame( match, isHost );

        //Now make sure the bots are there
        final StarcraftBot myBot = isHost ? match.getHomeBot() : match.getAwayBot();
        ensureBotIsOnDisc( myBot );
        ensureMapIsOnDisc( match.getMap() );

        boolean crashed = false;
        final File gameState = new File( starcraftBasePath + "\\" + GAMESTATE_FILE );
        if (gameState.exists()) {
            LOGGER.warning( "Found olf gamestate file. Removing." );
            gameState.delete();
        }
        //Start Starcraft
        final File chaosLauncher = new File( chaosLauncherPath );
        final Win32Process chaosProcess = Win32Process.create( chaosLauncher.getParent(), chaosLauncher.getAbsolutePath() );

        final long now = System.currentTimeMillis();

        final TournamentModuleState state = new TournamentModuleState();
        while (!gameState.canRead()) {
            try {
                LOGGER.info( GAMESTATE_FILE + " not there yet. Sleeping " + waitMs + "ms " );
                Thread.sleep( waitMs );

                if (now - System.currentTimeMillis() >= timeOutThreshold) {
                    LOGGER.info( "Waited more than " + timeOutThreshold + "ms. Reporting crash" );
                    crashed = true;
                    break;
                }
            }
            catch (final InterruptedException e) {
                throw new RuntimeException( e );
            }
        }
        LOGGER.info( "Found " + GAMESTATE_FILE );
        if (!crashed) {

            int lastFrame = -1;
            long lastUpdate = 0l;

            state.readData( gameState.getAbsolutePath() );
            while (!crashed && state.gameEnded == 0) {
                try {
                    Thread.sleep( waitMs );
                }
                catch (final InterruptedException e) {
                    throw new RuntimeException( e );
                }

                state.readData( gameState.getAbsolutePath() );
                LOGGER.info( "Frame count " + state.frameCount );

                if (state.frameCount > lastFrame) {
                    lastUpdate = System.currentTimeMillis();
                    lastFrame = state.frameCount;
                }
                else {
                    final long timeLeft = System.currentTimeMillis() - lastUpdate;
                    LOGGER.info( "No frame progression, " + (timeOutThreshold - timeLeft) + "ms until crash." );
                }

                // conditions under which we determine a crash
                crashed = System.currentTimeMillis() - lastUpdate > timeOutThreshold // frame update in X
                                || (checkIfProcessIsResponding ? !chaosProcess.isNotTerminated() : false) || state.frameCount == 0;

            }

            if (crashed) {
                LOGGER.info( "Starcraft seems to be non-responding or crashed" );

            }

        }
        //Send a reply
        prepReply( match, state, System.currentTimeMillis() - starttime, isHost, crashed );

        //Cleanup
        cleanup( chaosProcess );

    }

    private void prepReply( Match retGame, TournamentModuleState gameState, long elpasedTime, boolean isHost, boolean crashed ) throws IOException {

        LOGGER.info( "Prepped the results return for game: " + retGame.getGameID() + "(crashed=" + crashed + ")" );
        retGame.setFinalFrame( gameState.frameCount );
        if (isHost) {

            retGame.setHostTime( elpasedTime );
            retGame.setHostTimer55ms( gameState.timeOut55ms );
            retGame.setHostTimer1sec( gameState.timeOut1s );
            retGame.setHostTimer10sec( gameState.timeOut10s );

            if (crashed) {
                retGame.setHostcrash( true );
            }
            else {
                retGame.setWasDraw( gameState.gameHourUp > 0 );
                retGame.setFinalFrame( gameState.frameCount );
                retGame.setTimeout( gameState.gameHourUp == 1 );
                retGame.setHostwon( gameState.selfWin == 1 );
                retGame.setHostScore( gameState.selfScore );
            }

        }
        else {

            retGame.setGuestTime( elpasedTime );
            retGame.setAwayTimer55ms( gameState.timeOut55ms );
            retGame.setAwayTimer1sec( gameState.timeOut1s );
            retGame.setAwayTimer10sec( gameState.timeOut10s );

            if (crashed) {
                retGame.setAwaycrash( true );
            }
            else {
                retGame.setWasDraw( gameState.gameHourUp > 0 );
                retGame.setFinalFrame( gameState.frameCount );
                retGame.setTimeout( gameState.gameHourUp == 1 );
                retGame.setHostwon( gameState.selfWin == 0 );
                retGame.setAwayScore( gameState.selfScore );
            }
        }

        final String replayPath = starcraftBasePath + "\\" + retGame.replayPath( isHost );
        final File replay = new File( replayPath );
        if (replay.exists()) {
            final ByteBuffer replayBuffer = ByteBufferBackedInputStream.readStreamInputBuffer( new FileInputStream( replay ) );
            retGame.setReplay( replayBuffer );
            LOGGER.info( "Appending replay of " + replayBuffer.capacity() + " bytes" );
        }
    }

    private Message receiveMessage() throws IOException {
        final ByteBuffer headerBuffer = ByteBuffer.allocate( Integer.SIZE / 8 );
        int ret = sc.read( headerBuffer );
        if (ret < Integer.SIZE / 8) {
            throw new IllegalStateException( "Need at least 4 bytes" );
        }

        headerBuffer.rewind();
        final ByteBuffer messageBuffer = ByteBuffer.allocate( headerBuffer.getInt() );
        while (messageBuffer.hasRemaining()) {
            ret = sc.read( messageBuffer );
            if (ret < 0) {
                throw new IOException( "Server disconnected" );
            }
        }
        messageBuffer.rewind();
        final ObjectInputStream ooi = new ObjectInputStream( new ByteBufferBackedInputStream( messageBuffer ) );
        try {
            final Message m = (Message) ooi.readObject();
            return m;
        }
        catch (final ClassNotFoundException e) {
            throw new RuntimeException( e );
        }
        finally {
            ooi.close();
        }
    }

    @Override
    public void run() {
        try {
            connect();
            while (true) {
                sendMessage( new Message( MESSAGE_TYPE.READY ) );
                final Message m = receiveMessage();
                switch (m.getType()) {
                    case QUIT:
                        LOGGER.info( "Received quit signal. Shutting down" );
                        System.exit( 0 );
                        break;
                    case PLAY:
                        final Object[] payload = (Object[]) m.getPayload();
                        final Match match = (Match) payload[0];
                        final Boolean isHost = (Boolean) payload[1];
                        sendMessage( new Message( MESSAGE_TYPE.BUSY ) );
                        playGame( match, isHost );
                        final Message reply = new Message( MESSAGE_TYPE.RESULT, match );
                        sendMessage( reply );
                        break;
                    default:
                        System.out.println( m );
                        break;
                }
            }
        }
        catch (final Exception e) {
            LOGGER.severe( e.getLocalizedMessage() );
        }
        finally {
            try {
                cleanup( null );
            }
            catch (final IOException e) {

            }
            disconnect();
        }

    }

    private void sendMessage( Message m ) throws IOException {
        final ByteArrayOutputStream ba = new ByteArrayOutputStream();
        final ObjectOutputStream oos = new ObjectOutputStream( ba );
        oos.writeObject( m );
        LOGGER.info( "Sending message " + m );
        final ByteBuffer buffer = ByteBuffer.allocate( Integer.SIZE / 8 + ba.size() );
        buffer.putInt( ba.size() );
        buffer.put( ba.toByteArray() );
        buffer.rewind();

        sc.write( buffer );

    }

    private void setCheckOnProcess( String property ) {
        if (property == null) {
            checkIfProcessIsResponding = true;
        }
        else {
            checkIfProcessIsResponding = Boolean.parseBoolean( property );
        }
        LOGGER.info( "Set " + STRING_CHECK_RESPONDING + " = " + checkIfProcessIsResponding );
        if (checkIfProcessIsResponding) {
            LOGGER.info( "NOTE: if Starcraft gets terminated prematurely try setting " + STRING_CHECK_RESPONDING
                            + " = false. This will disable the check if the Starcraft process is responding what might not work correctly on some machines." );
        }

    }

    private void setConnection( String property ) {
        final Pattern p = Pattern.compile( "^\\s*(.*?):(\\d+)\\s*$" );
        final Matcher m = p.matcher( property );
        if (m.matches()) {
            try {
                host = m.group( 1 );
                port = Integer.parseInt( m.group( 2 ) );
            }
            catch (final Exception e) {
                throw new IllegalArgumentException( property + " is not a valid InetAddress with port, e.g. 127.0.0.1:1337" );
            }
        }
        else {
            throw new IllegalArgumentException( property + " is not a valid InetAddress with port, e.g. 127.0.0.1:1337" );
        }

    }

    private void setTickrate( String property ) {
        if (property == null) {
            waitMs = DEFAULT_TICKRATE;
        }
        else {
            try {
                waitMs = Integer.parseInt( property );
            }
            catch (final NumberFormatException e) {
                LOGGER.warning( STRING_TICKRATE + " = " + property + " is invalid" );
                waitMs = DEFAULT_TICKRATE;
            }
        }
        LOGGER.info( STRING_TICKRATE + " set to " + waitMs );

    }

    private void setTimeoutThreshold( String property ) {
        if (property == null) {
            timeOutThreshold = DEFAULT_TIMEOUT_THRESHOLD;
        }
        else {
            try {
                timeOutThreshold = Integer.parseInt( property );
            }
            catch (final NumberFormatException e) {
                LOGGER.warning( STRING_TIMEOUT + " = " + property + " is invalid" );
                timeOutThreshold = DEFAULT_TIMEOUT_THRESHOLD;
            }
        }
        LOGGER.info( STRING_TIMEOUT + " set to " + timeOutThreshold );
    }

}
